Skip to content

build(COMPASS-28): move to js-yaml 5 and ajv-formats 3 - #2721

Draft
timdawborn wants to merge 1 commit into
compass-28-jest-30from
compass-28-js-yaml-5-ajv-formats-3
Draft

build(COMPASS-28): move to js-yaml 5 and ajv-formats 3#2721
timdawborn wants to merge 1 commit into
compass-28-jest-30from
compass-28-js-yaml-5-ajv-formats-3

Conversation

@timdawborn

@timdawborn timdawborn commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Ticket

COMPASS-28 — Bump spot's NodeJS minimum from 18 to 22

PR 4 of 9. Staying in draft until the whole chain is verified.

Stacked on #2720#2719. Review those first — until they merge, the diff here includes their commits. This PR's own commit is 20845f0.

What

Package From To
js-yaml ^4.3.1 ^5.2.3
ajv-formats ^2.1.1 ^3.0.1
@types/js-yaml ^4.0.9 removed — v5 ships its own declarations

Supersedes Dependabot #2696 and #2255.

A supply-chain control that was silently inert

js-yaml is resolved at 5.2.3, not the 5.3.0 that ^5.2.3 also permits.

My first read of this blamed pnpm for ignoring minimumReleaseAge. That was wrong. The setting was never being applied, because the line below it is malformed:

minimumReleaseAge: 10080
minimumReleaseAgeExclude: "@airtasker/*"   # a string; pnpm wants a list

pnpm reads a scalar there as no exclusions and stops applying minimumReleaseAge altogether. Differential on the repo's own pnpm 10.28.1, spec ^5.2.3:

minimumReleaseAgeExclude shape resolves
"@airtasker/*" (scalar) 5.3.0
YAML list 5.2.3
removed entirely 5.2.3

So the seven-day quarantine has been covering nothing, for every package — a control that reads as present and does less than absent, because it invites trust.

Fixed here as a list. A fresh resolution of the same range now picks 5.2.3, the exclusion still parses, and already-pinned versions are not re-resolved — verified no lockfile in the stack moves. That is also why the lockfile pin stays: it is what holds the line for this package until the config fix reaches master.

Corrected too: 5.3.0 was 5.8 days old at commit time, not "four days" as this description first said.

Emitted output is unchanged

js-yaml 5 is the bump most able to change every -l yaml artifact — quoting, indentation, line folding. It did not:

diff -r /tmp/y-before /tmp/y-after   # empty

All seven generator × language combinations from test-fixtures/contract/api.ts are byte-identical across the major.

ajv-formats was imported under the wrong name

-import Ajv from "ajv-formats";
+import addFormats from "ajv-formats";

The default export is addFormats — it takes an Ajv instance, registers the format validators on it, and returns it. The old name made the call site read as a constructor wrapping another constructor:

const jsv = Ajv(new JsonSchemaValidator({ ... }));   // before
const jsv = addFormats(new JsonSchemaValidator({ ... }));  // after

Behaviour is unchanged; this was always addFormats.

Format registration is the one thing an ajv-formats major can break silently — a body that is wrong only on a format keyword would validate clean, and both date and date-time are emitted by the JSON Schema generator. It is already covered: contract-mismatcher.spec.ts's request-body date test asserts ajv's own error text verbatim —

- #/data/createdAt must match format "date"

— so the formats must be registered for that test to pass. I also probed addFormats(new Ajv()) directly and confirmed it accepts valid date / date-time values, rejects invalid ones, and returns the instance. No new test needed; the existing one is the right shape.

How this was verified

Locally on Node 22.23.2, all exit 0:

Check Result
pnpm install 0
pnpm build 0
pnpm test 0 — 54 suites, 553 tests, 44 snapshots
pnpm lint:check 0
pnpm build-docs 0
docker build + check-image-parity 0 — all checks passed
validate / generate -l yaml / --help 0
generated-output diff vs base empty

@timdawborn

Copy link
Copy Markdown
Contributor Author

Automated review — /pr-review-toolkit:review-pr

Agents run: code-reviewer, comment-analyzer. Scoped to this PR's own commit (20845f0), not the diff against master, so ancestors in the stack are excluded.

🔴 Critical (1)

pnpm-workspace.yaml:15minimumReleaseAgeExclude is a scalar where pnpm expects a list, which silently disables minimumReleaseAge for every package in the repo.

This corrects the diagnosis in this PR's own description. I attributed js-yaml 5.3.0 slipping through to pnpm ignoring the setting. It isn't — it's a malformed line in this repo. Reproduced independently with the repo's own pnpm 10.28.1 and spec ^5.2.3:

minimumReleaseAgeExclude shape resolves
"@airtasker/*" (scalar — repo today) js-yaml@5.3.0
YAML list (- "@airtasker/*") js-yaml@5.2.3
removed entirely js-yaml@5.2.3
# current — parses as a str, and the whole gate goes quiet
minimumReleaseAgeExclude: "@airtasker/*"

# fix
minimumReleaseAgeExclude:
  - "@airtasker/*"

Consequence: the 7-day quarantine has been off repo-wide, not just for js-yaml. That's a supply-chain control that reads as present and does nothing — worse than absent, because it invites trust. The lockfile pin in this PR holds the line for one package and expires the moment 5.3.0 clears the window legitimately.

Also corrected: 5.3.0 was 5.8 days old at commit time, not "four days" as the description says.

🟡 Important (0)

None.

🔵 Suggestions (1)

lib/src/validation-server/verifications/contract-mismatcher.ts:416 — the comment says addFormats "adds the format validators to the instance and returns it", which is true but not the whole mutation: with no options it also registers the formatMaximum/formatMinimum/formatExclusiveMaximum/formatExclusiveMinimum keywords and sets ajv.opts.code.formats. The durable fact for the next reader is the same instance is returned — that's what makes wrapping the constructor safe.

✅ Verified sound

  • The rename is correct and complete. One import, one call site, no other ajv-formats reference in the tree; addFormats(ajv) does register-and-return (checked against ajv-formats@3.0.1 source, which ends return ajv).
  • Dropping @types/js-yaml carries no risk. js-yaml 5 ships dist/js-yaml.d.ts with the same named exports; esModuleInterop plus no __esModule in the CJS bundle makes import YAML from "js-yaml" sound at both type and runtime level.
  • Output parity independently reproduced beyond what this PR claimed: v4.3.1 vs v5.2.3 dump(..., {skipInvalid: true}) byte-identical across 18 edge cases (folding, anchors, quoting, dates, unicode, indicator chars), not just the generator fixtures.
  • The comment states current behaviour rather than narrating the rename, and carries no ticket shortcode.

Recommended action

  1. Fix the minimumReleaseAgeExclude shape — one line, and it restores a control that is currently inert across the whole repo. Either here or in a linked follow-up; it should not merge as undocumented prose.
  2. Optionally sharpen the addFormats comment to lead with "returns the same instance".

`js-yaml` to ^5.2.3 and `ajv-formats` to ^3.0.1. `@types/js-yaml` is
removed: js-yaml 5 ships its own declarations.

`minimumReleaseAgeExclude` was a string where pnpm wants a list, and pnpm
reads a scalar there as no exclusions and stops applying
`minimumReleaseAge` altogether. So the seven-day quarantine has been
covering nothing, for every package, not only this one. That is why
`^5.2.3` resolved 5.3.0, five days old at the time. With the list form a
fresh resolution of the same range picks 5.2.3, and the exclusion still
parses. Already-pinned versions are not re-resolved, so no lockfile in the
stack moves.

Emitted output is unchanged. All seven generator and language combinations
are byte-identical across the js-yaml major, which is the bump that could
have altered quoting or indentation in every `-l yaml` artifact.

`ajv-formats` was imported as `Ajv` and called as `Ajv(...)`, though it is
`addFormats` — it takes an Ajv instance, registers the format validators on
it and returns it. Renamed to what it is, since the next reader of a line
that constructs a validator inside a call to `Ajv` has to open the package
to find out which one is which.

Format registration is what an ajv-formats major can silently break, and a
body that fails only on a format keyword would otherwise validate clean.
It is already covered: the request-body date test asserts ajv's own
`must match format "date"` text, so the formats have to be registered for
it to pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@timdawborn
timdawborn force-pushed the compass-28-js-yaml-5-ajv-formats-3 branch from 20845f0 to c1123a5 Compare August 20, 2026 03:11
@timdawborn

Copy link
Copy Markdown
Contributor Author

Addressed in c1123a5. The Critical finding was sound and I have corrected this PR's description, which had the wrong cause: minimumReleaseAgeExclude was a scalar where pnpm wants a list, and pnpm reads that as no exclusions and stops applying minimumReleaseAge entirely — so the quarantine covered nothing, repo-wide. Fixed as a list; a fresh resolution of ^5.2.3 now picks 5.2.3, and I verified no lockfile in the stack is perturbed since pnpm does not re-resolve what is already pinned. Left the addFormats suggestion alone to keep this commit to the one behaviour change.

@timdawborn

Copy link
Copy Markdown
Contributor Author

Automated review, round 2 — /pr-review-toolkit:review-pr

Agent: code-reviewer. Scoped to the revised commit c1123a5.

The round-1 fix is sound — re-verified independently under the repo's pinned pnpm 10.28.1 (the agent's first probe silently used a global pnpm 11.6.0 and it re-ran). But the fix is in the wrong place, and its evidence expires tomorrow.

🔴 Critical (1)

pnpm-workspace.yaml:17-18 — this file is org-synced, so the fix is local to one repo out of ten and the next sync will revert it.

The block carries an identical header and Confluence link across repos. I sampled five siblings plus spot@master and every one has the scalar form:

openapi-generators   minimumReleaseAgeExclude: "@airtasker/*"
proxay               minimumReleaseAgeExclude: '@airtasker/*'
web                  minimumReleaseAgeExclude: '@airtasker/*'
mcp-server           minimumReleaseAgeExclude: "@airtasker/*"
seo-engine           minimumReleaseAgeExclude: '@airtasker/*'
spot@master          minimumReleaseAgeExclude: "@airtasker/*"

So minimumReleaseAge: 10080 is inert across the org, not just here. Two consequences:

  1. Fixing spot alone leaves the other repos with no quarantine.
  2. The next template sync rewrites this line back to a scalar — silently re-disabling the gate, with no signal and no CI failure. My fix has a half-life.

This needs the upstream template fixed, with a linked follow-up ticket named at the site so the next sync-driven revert is recognisable. Worth noting the blast radius is bounded but not closed by .github/dependabot.yml's independent cooldown: default-days: 7 — that covers bot bumps only, which is precisely why a manual bump let js-yaml 5.3.0 through.

🟡 Important (1)

The commit message's evidence stops reproducing in ~28 hours. js-yaml 5.3.0 published 2026-08-14T09:31:39Z; at 10080 minutes the quarantine lifts 2026-08-21T09:31:39Z — confirmed against the registry, 28.2 hours from now. So "a fresh resolution of the same range picks 5.2.3" becomes false tomorrow, and a reviewer re-running it will read 5.3.0 as a regression rather than expected behaviour.

State the invariant — "the gate refuses any release under seven days old" — not the version that happened to demonstrate it.

Related: the lockfile pin of 5.2.3 is not redundant, but the config fix is not what holds it either. Nothing re-resolves it, and from tomorrow the gate permits 5.3.0. If 5.2.3 is wanted for a reason beyond "5.3.0 was too new", that reason is recorded nowhere and the next pnpm up takes 5.3.0. If not, no action needed — but the current framing implies a durability it does not have.

✅ Verified sound

Every claim re-checked by running it under pnpm 10.28.1:

Check Result
list form, ^5.2.3 5.2.3
scalar form, same range 5.3.0 — the round-1 diagnosis holds
exclusion still honoured Yes — ["js-yaml"] → 5.3.0; multi-entry lists work
scoped-glob semantics intact Yes — ["@types/*"] matched @types/node; ["@airtasker/*"] correctly did not
pnpm install --frozen-lockfile succeeds
full re-resolution with the gate live exit 0, zero refusals — nothing currently pinned is refused
suite 555 tests / 44 snapshots pass
skipInvalid still supported in js-yaml 5 yes (js-yaml.d.ts:333)
byte-identical YAML claim independently reproduced across all three generators

That second-to-last row is the one that matters for merge confidence: turning the gate on does not retroactively refuse anything already in the lockfile.

The addFormats rename and its comment are accurate, and covered by the passing must match format "date" assertion.

Recommended action

  1. Raise the fix upstream in the synced template, and name a follow-up ticket at the site here so a sync-driven revert is recognisable rather than invisible.
  2. Reword the commit message to state the invariant rather than the version — it stops being true tomorrow.
  3. Decide whether the 5.2.3 lockfile pin should stay once the gate permits 5.3.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant